6  Анализ частотностей

Основные этапы NLP включают в себя токенизацию, морфологический и синтаксический анализ, а также анализ семантики и прагматики. В этом уроке речь пойдет про первые три этапа. Мы научимся разбивать текст на токены (слова), определять морфологические характеристики слов и находить их начальные формы (леммы), а также анализировать структуру предложения с использованием синтаксических парсеров.

library(tidyverse)
library(tidytext)
library(udpipe)

6.1 Данные

За основу для всех эти вычислений мы возьмем три философских трактата, написанных на английском языке. Это хронологически и тематически близкие тексты:

  • “Опыт о человеческом разумении” Джона Локка (1690), первые две книги;
  • “Трактат о принципах человеческого знания” Джорджа Беркли (1710);
  • “Исследование о человеческом разумении” Дэвида Юма (1748).

Данные были извлечены с использованием библиотеки Gutengerg и приведены к опрятному виду. Скачать подготовленный таким образом корпус можно здесь.

load("../data/emp_corpus.Rdata")

Делим корпус на слова уже известным вам способом.

corpus_words <- emp_corpus |> 
  unnest_tokens(word, text)

corpus_words

Большая часть слов, которые мы сейчас видим в корпусе – это шумовые слова, или стоп-слова, не несущие смысловой нагрузки.

corpus_words |> 
  count(word, sort = TRUE)  |> 
  slice_head(n = 15) |> 
  ggplot(aes(reorder(word, n), n, fill = word)) +
  geom_col(show.legend = F) + 
  coord_flip() 

Стоп-слова не обязательно удалять вручную, как мы делали ранее. Можно отобрать наиболее характерные слова или отсортировать по частям речи.

6.2 Наиболее характерные слова

Но абсолютная частотность – плохой показатель для текстов разной длины. Чтобы тексты было проще сравнивать, мы можем разделить показатели частотности на общее число токенов в тексте.

Cначала считаем частотность для всех токенов по авторам.

author_word_counts <- corpus_words  |> 
  count(author, word, sort = T) 

author_word_counts

Наиболее частотные слова наименее подвержены влиянию тематики, поэтому их используют для стилометрического анализа. Если отобрать наиболее частотные после удаления стоп-слов, то мы получим достаточно адекватное отражение тематики документов. Если же мы необходимо найти наиболее характерные для документов токены, то применяется другая мера, которая называется tf-idf (term frequency - inverse document frequency).

Логарифм единицы равен нулю, поэтому если слово встречается во всех документах, его tf-idf равно нулю. Чем выше tf-idf, тем более характерно некое слово для документа. При этом относительная частотность тоже учитывается! Например, Беркли один раз упоминает “сахарные бобы”, а Локк – “миндаль”, но из-за редкой частотности tf-idf для подобных слов будет низкой.

Функция bind_tf_idf() принимает на входе тиббл с абсолютной частотностью для каждого слова.

author_word_tfidf <- author_word_counts |> 
  bind_tf_idf(word, author, n)

author_word_tfidf

Посмотрим на слова с высокой tf-idf:

author_word_tfidf |> 
  arrange(-tf_idf)

Теперь визуализируем.

author_word_tfidf |> 
  arrange(-tf_idf) |> 
  group_by(author) |> 
  top_n(15) |> 
  ungroup() |> 
  ggplot(aes(reorder_within(word, tf_idf, author), tf_idf, fill = author)) +
  geom_col(show.legend = F) +
  labs(x = NULL, y = "tf-idf") +
  facet_wrap(~author, scales = "free") +
  scale_x_reordered() +
  coord_flip()

На такой диаграмме авторы совсем не похожи друг на друга, но будьте осторожны: все то, что их сближает (а это не только служебные части речи!), сюда просто не попало. Можно также заметить, что ряд характерных слов связаны не столько с тематикой, сколько со стилем: чтобы этого избежать, можно использовать лемматизацию или задать правило для замены вручную.

6.3 Лемматизация и POS-тэггинг

Лемматизация – приведение слов к начальной форме (лемме). Как правило, она проводится одновременно с частеречной разметкой (POS-tagging). Все это умеет делать UDPipe – обучаемый конвейер (trainable pipeline), для которого существует одноименный пакет в R.

Основным форматом файла для него является CoNLL-U. Файлы в таком формате хранятся в так называемых трибанках, то есть коллекциях уже размеченных текстов (название объясняется тем, что синтаксическая структура предложений представлена в них в виде древовидных графов). Файлы CoNLL-U используются для обучения нейросетей, но для большинства языков доступны хорошие предобученные модели, работать с которыми достаточно просто.

Пакет udpipe позволяет работать со множеством языков (всего 65), для многих из которых представлено несколько моделей, обученных на разных трибанках. Прежде всего нужно выбрать и загрузить модель (список). Описания моделей доступны на сайте https://universaldependencies.org/.

# скачиваем модель в рабочую директорию
# udpipe_download_model(language = "english-gum")

# загружаем модель
english_gum <- udpipe_load_model(file = "english-gum-ud-2.5-191206.udpipe")

# аннотируем (это займет несколько минут)
emp_ann <- udpipe_annotate(english_gum, emp_corpus$text, doc_id = emp_corpus$author)

Результат возвращается в формате CoNLL-U; это широко применяемый формат представления результат морфологического и синтаксического анализа текстов.

Вот пример разбора предложения:

Cтроки слов содержат следующие поля:

  1. ID: индекс слова, целое число, начиная с 1 для каждого нового предложения; может быть диапазоном токенов с несколькими словами.
  2. FORM: словоформа или знак препинания.
  3. LEMMA: Лемма или основа словоформы.
  4. UPOSTAG: тег части речи из универсального набора проекта UD, который создавался для того, чтобы аннотации разных языков были сравнимы между собой.
  5. XPOSTAG: тег части речи, который выбрали исследователи под конкретные нужды языка
  6. FEATS: список морфологических характеристик.
  7. HEAD: идентификатор (номер) синтаксической вершины текущего токена. Если такой вершины нет, то ставят ноль (0).
  8. DEPREL: характер синтаксической зависимости.
  9. DEPS: Список вторичных зависимостей.
  10. MISC: любая другая аннотация.

Для работы данные удобнее трансформировать в прямоугольный формат.

emp_pos <- as_tibble(emp_ann) |> 
  select(-paragraph_id)

emp_pos 

6.3.1 Поля UPOS и XPOS

Морфологическая аннотация, которую мы получили, дает возможность выбирать и группировать различные части речи. Например, существительные.

emp_pos |> 
  filter(upos == "NOUN") |> 
  select(doc_id, token, lemma, upos, xpos)

Посчитать части речи можно так:

upos_counts <- emp_pos |>
  count(doc_id, upos, sort = TRUE) 

upos_counts

Относительные значения:

total_counts <- upos_counts |> 
 count(doc_id, wt = n, name = "sum_n")

pos_share <- upos_counts |> 
  left_join(total_counts) |> 
  mutate(share = round( (n/sum_n), 2))

pos_share |> 
  ggplot(aes(reorder(upos, share), share, fill = upos)) +
  geom_col(show.legend = FALSE) +
  coord_flip() +
  theme_light() +
  facet_wrap(~doc_id, scales = "free")

Отберем наиболее частотные имена и имена собственные.

nouns <- emp_pos  |> 
  filter(upos %in% c("NOUN", "PROPN")) |> 
  count(doc_id, lemma, sort = TRUE) 

nouns
nouns |> 
  group_by(doc_id) |> 
  slice_head(n = 10) |> 
  ggplot(aes(reorder(lemma, n), n, fill = lemma)) +
  geom_col(show.legend = FALSE) +
  theme_light() +
  coord_flip() +
  facet_wrap(~ doc_id, scales = "free")

Сравните с результатом, который вы получили, считая TF-IDF.

В отличие от UPOS (Universal Part-Of-Speech), XPOS (Language-specific Part-Of-Speech) – это теги частей речи, используемые в национальных корпусах. Форматы тегов и их детализация могут значительно меняться от языка к языку.

6.3.2 Поля FEATS и DEP_REL

Допустим, нам нужны лишь определенные формы: например, прилагательные в превосходной степени.

superlatives <- emp_pos  |> 
  filter(str_detect(feats, "Degree=Sup") & upos == "ADJ") |> 
  select(doc_id, token)

superlatives |> 
  count(doc_id, token, sort = TRUE)

Аналогичным образом можно отбирать синтаксические признаки (DEP_REL) и их комбинации, а также визуализировать деревья зависимостей для отдельных предложений при помощи пакета {textplot}.

6.4 Совместная встречаемость слов

Функция cooccurence() из пакета udpipe позволяет выяснить, сколько раз некий термин встречается совместно с другим термином, например:

  • слова встречаются в одном и том же документе/предложении/параграфе;
  • слова следуют за другим словом;
  • слова находятся по соседству с другим словом на расстоянии n слов.

Выясним, какие существительные чаще встречаются в одном предложении у Локка:

locke_subset <-  subset(emp_pos, doc_id = "Locke")

locke_nouns <- locke_subset |> 
  filter(upos == "NOUN")

cooc <- cooccurrence(locke_nouns, term = "lemma", 
                     group = c("doc_id", "sentence_id")) |>
  as_tibble() |> 
  filter(cooc > 100)

cooc

Этот результат легко визуализировать, используя пакет ggraph:

library(igraph)
library(ggraph)

wordnetwork <- graph_from_data_frame(cooc)

ggraph(wordnetwork, layout = "fr") +
  geom_edge_arc(aes(width = cooc), alpha = 0.8, edge_colour = "grey90", show.legend=FALSE) +
  geom_node_label(aes(label = name), col = "#1f78b4", size = 4) +
  theme_void() +
  labs(title = "Совместная встречаемость существительных в предложении")

Чтобы узнать, какие слова чаще стоят рядом, используем ту же функцию, но с другими аргументами:

cooc2 <- cooccurrence(locke_subset$lemma, 
                      relevant = locke_subset$upos %in% c("NOUN", "ADJ"), 
                      skipgram = 1) |> 
  as_tibble() |> 
  filter(cooc > 20)

cooc2
wordnetwork <- graph_from_data_frame(cooc2)

ggraph(wordnetwork, layout = "fr") +
  geom_edge_link(aes(width = cooc), edge_colour = "grey90", edge_alpha=0.8, show.legend = F) +
  geom_node_label(aes(label = name), col = "#1f78b4") +
  labs(title = "Слова, стоящие рядом в тексте") +
  theme_void()
Warning in geom_node_label(aes(label = name), col = "#1f78b4"): Ignoring
unknown parameters: `label.size`